第 9 章 任务调度和邮件发送
写给新手的话:
前面我们学了怎么写接口、怎么操作数据库、怎么用消息队列。
这一章我们学两个非常实用的功能:异步任务和定时任务,还有邮件发送。
这些都是项目里经常用到的:
- 用户注册,发邮件发短信,不用让用户等 → 异步任务
- 每天凌晨统计报表、清理日志 → 定时任务
- 注册验证、找回密码、通知提醒 → 邮件发送
这一章内容不难,但是非常实用,一定要掌握。
学习建议:
- 异步任务很简单,加两个注解就搞定
- 定时任务重点是 cron 表达式,多练几个例子就会了
- Quartz 更强大,适合复杂的调度需求
- 邮件发送注意配置和各种类型的邮件
准备好了吗?我们开始吧!
9.1 异步任务
9.1.1 什么是异步任务
先回忆一下同步和异步的区别:
同步:
- 调用方法,要等方法执行完才返回
- 前面的事没做完,后面的事等着
- 用户要等很久,体验不好
异步:
- 调用方法,不用等,直接返回
- 方法在后台慢慢执行
- 用户不用等,体验好
打个比方:
- 同步:你去饭馆点菜,站在厨房门口等,菜做好了端走
- 异步:你点完菜找个位置坐着玩手机,菜做好了服务员给你端过来
显然异步体验更好。
什么时候用异步?
- 发邮件、发短信
- 生成报表、导出文件
- 日志记录
- 数据同步
- 任何耗时的、不需要立即返回结果的操作
9.1.2 Spring Boot 实现异步任务
Spring Boot 实现异步任务非常简单,两个注解搞定:
@EnableAsync:开启异步支持(加在启动类上)@Async:标记异步方法(加在方法上)
就这么简单!
9.1.3 快速入门
步骤 1:开启异步支持
@SpringBootApplication
@EnableAsync // 开启异步支持
public class Chapter09Application {
public static void main(String[] args) {
SpringApplication.run(Chapter09Application.class, args);
}
}步骤 2:写异步方法
@Service
public class AsyncTaskService {
/**
* 无返回值的异步方法
*/
@Async
public void sendSMS() throws InterruptedException {
System.out.println("开始发送短信...");
// 模拟发短信耗时 5 秒
Thread.sleep(5000);
System.out.println("短信发送完成");
}
}步骤 3:调用测试
@RestController
public class TaskController {
@Autowired
private AsyncTaskService taskService;
@RequestMapping("/sendSMS")
public String sendSMS() throws InterruptedException {
// 调用异步方法,立即返回,不用等 5 秒
taskService.sendSMS();
return "短信发送中,请稍候...";
}
}测试一下:
启动项目,访问 http://localhost:8080/sendSMS
你会发现:
- 浏览器立即返回结果,不用等 5 秒
- 控制台在后台慢慢输出"短信发送完成"
就这么简单!加两个注解就实现了异步。 是不是很方便?
9.1.4 有返回值的异步任务
有时候异步任务执行完,我们需要拿到结果。
这时候用 Future<T> 作为返回值。
@Service
public class AsyncTaskService {
/**
* 有返回值的异步方法
*/
@Async
public Future<Integer> salesStatistics(String area) throws InterruptedException {
System.out.println("开始统计【" + area + "】的销售额...");
// 模拟统计耗时(1-5秒随机)
int time = new Random().nextInt(5) + 1;
Thread.sleep(time * 1000);
int result = time + 1000; // 模拟统计结果
System.out.println(area + " 统计完成:" + result + " 元");
return new AsyncResult<>(result);
}
}调用有返回值的异步方法:
@RequestMapping("/statistics")
public String salesStatistics() throws Exception {
long start = System.currentTimeMillis();
// 同时启动两个异步任务
Future<Integer> f1 = taskService.salesStatistics("北京");
Future<Integer> f2 = taskService.salesStatistics("上海");
// 获取结果(会阻塞,等任务完成)
Integer total = f1.get() + f2.get();
long end = System.currentTimeMillis();
System.out.println("总计:" + total + " 元,总耗时:" + (end - start) + " 毫秒");
return "统计完成,总计:" + total + " 元";
}Future 常用方法:
| 方法 | 说明 |
|---|---|
get() | 获取结果,阻塞等待 |
get(timeout, unit) | 获取结果,最多等多久 |
isDone() | 任务是否完成 |
cancel(mayInterruptIfRunning) | 取消任务 |
isCancelled() | 是否被取消了 |
有返回值的异步任务,可以并行执行多个任务,最后汇总结果。 比如同时查多个接口、统计多个地区,比串行快很多。
9.1.5 自定义线程池
默认情况下,Spring 用的是 SimpleAsyncTaskExecutor,每次都新建线程,不太好。
实际项目中,我们一般自定义线程池。
配置类:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(10);
// 最大线程数
executor.setMaxPoolSize(20);
// 队列容量
executor.setQueueCapacity(200);
// 线程名前缀
executor.setThreadNamePrefix("async-task-");
// 拒绝策略:由调用线程处理
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 等待所有任务完成后再关闭
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}使用指定的线程池:
@Async("taskExecutor") // 指定用哪个线程池
public void sendSMS() {
// ...
}线程池参数怎么设?
简单的估算方法:
- CPU 密集型(计算多):核心线程数 = CPU 核心数 + 1
- IO 密集型(读写多、等待多):核心线程数 = CPU 核心数 * 2
实际项目中,根据压测结果调整。 不要拍脑袋设,也不是越大越好。
9.1.6 异步任务的异常处理
无返回值的异步方法,如果抛异常了,默认是没人管的。
我们可以配置全局的异常处理器。
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new AsyncUncaughtExceptionHandler() {
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
System.out.println("异步任务异常:" + method.getName());
System.out.println("异常信息:" + ex.getMessage());
// 可以做告警、记录日志等
}
};
}
}重要的异步任务,一定要处理异常,不然出了问题都不知道。
9.2 定时任务(Spring Schedule)
9.2.1 什么是定时任务
定时任务就是:到了指定时间,自动执行某个任务。
常见场景:
- 每天凌晨统计前一天的数据
- 每隔 5 分钟检查一次系统状态
- 每个月 1 号生成月报
- 每周一早上发周报
- 定时清理临时文件、过期日志
Spring Boot 自带了定时任务支持,非常方便。
9.2.2 快速入门
两个注解搞定:
@EnableScheduling:开启定时任务(加在启动类上)@Scheduled:标记定时方法(加在方法上)
步骤 1:开启定时任务
@SpringBootApplication
@EnableScheduling // 开启定时任务
public class Chapter09Application {
public static void main(String[] args) {
SpringApplication.run(Chapter09Application.class, args);
}
}步骤 2:写定时方法
@Service
public class ScheduledTaskService {
/**
* 每隔 2 秒执行一次
*/
@Scheduled(fixedRate = 2000)
public void fixedRateTask() {
System.out.println("fixedRate 执行:" + LocalDateTime.now());
}
}启动项目,你会看到控制台每隔 2 秒输出一次。
就这么简单!两个注解搞定定时任务。
9.2.3 @Scheduled 的三种方式
@Scheduled 有三种常用的配置方式:
1. fixedRate:固定频率
从上一次开始时间算起,每隔多久执行一次。
// 每隔 2 秒执行一次(从上一次开始时间算)
@Scheduled(fixedRate = 2000)
public void fixedRateTask() {
System.out.println("fixedRate 执行");
}特点:
- 固定频率,不管上次执行多久
- 如果任务执行时间超过间隔,会等上次执行完立即执行下一次
- 不会并发执行(同一个任务)
2. fixedDelay:固定延迟
从上一次结束时间算起,隔多久执行下一次。
// 上一次执行完后,隔 1 秒再执行下一次
@Scheduled(fixedDelay = 1000)
public void fixedDelayTask() throws InterruptedException {
System.out.println("fixedDelay 执行");
Thread.sleep(2000); // 模拟执行 2 秒
}特点:
- 两次执行之间有固定的间隔
- 间隔是从上一次结束开始算
- 任务执行时间不固定的话,总周期也不固定
3. initialDelay:初始延迟
第一次执行前,先等多久。
// 启动后先等 5 秒,然后每隔 2 秒执行一次
@Scheduled(fixedRate = 2000, initialDelay = 5000)
public void taskWithInitialDelay() {
System.out.println("执行");
}用途:
- 项目启动后,等初始化完成了再执行定时任务
- 避免启动时还没准备好就执行
4. cron:Cron 表达式
最灵活的方式,用 cron 表达式指定执行时间。
// 每秒执行一次
@Scheduled(cron = "* * * * * ?")
public void cronTask() {
System.out.println("cron 执行:" + LocalDateTime.now());
}cron 表达式最强大,也最常用,下一节详细讲。
9.2.4 fixedRate vs fixedDelay
很多人搞不清这两个的区别,我们来对比一下:
| 方式 | 计算起点 | 特点 | 适用场景 |
|---|---|---|---|
fixedRate | 上一次开始时间 | 固定频率 | 需要固定频率执行的 |
fixedDelay | 上一次结束时间 | 固定间隔 | 两次执行之间需要休息的 |
举个例子:
假设间隔 2 秒,任务执行 1 秒:
fixedRate:
0s 开始执行 → 1s 结束 → 等 1s → 2s 开始执行 → 3s 结束...
(每 2 秒开始一次)
fixedDelay:
0s 开始执行 → 1s 结束 → 等 2s → 3s 开始执行 → 4s 结束...
(两次结束之间隔 2 秒)如果任务执行时间超过间隔(比如任务执行 3 秒,间隔 2 秒):
fixedRate:
0s 开始 → 3s 结束 → 立即开始下一次(因为 2s 的点已经过了)
(不会并发,会排队)
fixedDelay:
0s 开始 → 3s 结束 → 等 2s → 5s 开始...
(还是结束后等 2 秒)简单记:
- fixedRate = 固定频率(按开始时间算)
- fixedDelay = 固定延迟(按结束时间算)
9.2.5 Cron 表达式详解
Cron 表达式是定时任务里最重要的,也是面试常问的。
Cron 表达式格式:
秒 分 时 日 月 周 年共 7 个位置,最后一个"年"可以省略。
各位置的取值范围:
| 位置 | 字段 | 取值范围 | 特殊字符 |
|---|---|---|---|
| 1 | 秒 | 0-59 | , - * / |
| 2 | 分 | 0-59 | , - * / |
| 3 | 时 | 0-23 | , - * / |
| 4 | 日 | 1-31 | , - * / ? L W |
| 5 | 月 | 1-12 | , - * / |
| 6 | 周 | 1-7(1=周日) | , - * / ? L # |
| 7 | 年(可选) | 1970-2099 | , - * / |
特殊字符说明:
| 字符 | 含义 | 例子 |
|---|---|---|
* | 任意值 | * 表示每秒/每分/每时 |
? | 不指定(日和周互斥,一个设了另一个用 ?) | 日设了,周就用 ? |
, | 列表 | 1,3,5 表示 1、3、5 |
- | 范围 | 1-5 表示 1 到 5 |
/ | 步长 | 0/5 表示从 0 开始,每 5 个 |
L | 最后(Last) | 日的 L = 当月最后一天 |
W | 工作日(Weekday) | 15W 表示离 15 号最近的工作日 |
# | 第几个 | 3#2 表示第 2 个周三 |
注意:日和周是互斥的,一个指定了具体值,另一个就要用 ?。 不然会冲突,比如 15 号不一定是周一。
9.2.6 常用 Cron 例子
| 表达式 | 含义 |
|---|---|
0 0 0 * * ? | 每天凌晨 0 点 |
0 0 2 * * ? | 每天凌晨 2 点 |
0 0 12 * * ? | 每天中午 12 点 |
0 0/5 * * * ? | 每隔 5 分钟 |
0 */10 * * * ? | 每隔 10 分钟 |
0 0 9-18 * * ? | 每天 9 点到 18 点,整点执行 |
0 0 9 ? * MON-FRI | 周一到周五早上 9 点 |
0 0 0 1 * ? | 每月 1 号凌晨 |
0 0 0 L * ? | 每月最后一天凌晨 |
0 0 0 ? * 1 | 每周日凌晨 |
0 0 0 ? * 2 | 每周一凌晨 |
0 0 0 ? * 6L | 每月最后一个周五凌晨 |
0 0 0 15W * ? | 每月 15 号最近的工作日 |
0 0 0 ? * 3#2 | 每月第 2 个周三 |
不用死记,用的时候查一下就行。 网上有很多 cron 表达式生成器,直接搜"cron 在线生成"。
9.2.7 定时任务的注意事项
默认单线程
- Spring Schedule 默认是单线程的
- 多个定时任务会排队执行
- 如果一个任务卡住了,其他任务都受影响
自定义线程池
- 可以配置线程池,让多个任务并行执行
@Configuration
@EnableScheduling
public class ScheduleConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.initialize();
taskRegistrar.setTaskScheduler(scheduler);
}
}任务不要抛异常
- 定时任务抛异常,下一次还会继续执行
- 但最好自己 try-catch,记录日志
任务不要太长
- 定时任务不要执行太久
- 不然影响下一次执行
- 耗时的任务考虑用异步 + 定时触发
集群部署要注意
- 多个实例的话,定时任务会执行多次
- 解决:分布式锁、只在一个节点执行、用 Quartz 集群模式
Spring Schedule 适合简单的定时任务。 复杂的、需要持久化、需要集群的,用 Quartz。
9.3 Quartz 任务调度
9.3.1 Quartz 是什么
Quartz 是一个功能强大的任务调度框架。
跟 Spring Schedule 比:
| 特性 | Spring Schedule | Quartz |
|---|---|---|
| 上手难度 | 简单 | 稍复杂 |
| 功能 | 基础 | 强大 |
| 持久化 | 不支持 | 支持 |
| 集群 | 不支持 | 支持 |
| 动态创建 | 不方便 | 方便 |
| 任务管理 | 弱 | 强(暂停、恢复、删除) |
什么时候用 Quartz?
- 任务比较多,需要管理
- 需要动态创建、修改、删除任务
- 需要持久化(重启不丢)
- 需要集群部署
- 需要复杂的调度规则
简单的定时任务用 Spring Schedule 就行。 复杂的、企业级的用 Quartz。
9.3.2 Quartz 核心概念
Quartz 有几个核心概念,先搞清楚:
| 概念 | 说明 |
|---|---|
| Job | 任务,要做什么事 |
| JobDetail | 任务详情,定义任务的信息 |
| Trigger | 触发器,什么时候触发 |
| Scheduler | 调度器,负责调度任务 |
简单理解:
- Job = 做什么
- Trigger = 什么时候做
- Scheduler = 负责安排什么时候做什么
9.3.3 Spring Boot 整合 Quartz
步骤 1:加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>步骤 2:定义任务类
继承 QuartzJobBean,实现 executeInternal 方法。
public class MyTask extends QuartzJobBean {
@Override
protected void executeInternal(JobExecutionContext context) {
System.out.println("MyTask 执行:" + LocalDateTime.now());
}
}步骤 3:配置 JobDetail 和 Trigger
@Configuration
public class QuartzConfig {
/**
* 定义 JobDetail(任务详情)
*/
@Bean
public JobDetail jobDetail() {
return JobBuilder.newJob(MyTask.class)
.withIdentity("myTask") // 任务名称(唯一标识)
.storeDurably() // 任务执行完保留
.build();
}
/**
* 定义 Trigger(触发器)
*/
@Bean
public Trigger myTaskTrigger() {
// Cron 表达式:每秒执行一次
CronScheduleBuilder schedule =
CronScheduleBuilder.cronSchedule("* * * * * ?");
return TriggerBuilder.newTrigger()
.forJob(jobDetail()) // 绑定哪个任务
.withIdentity("myTaskTrigger") // 触发器名称
.withSchedule(schedule) // 调度规则
.build();
}
}步骤 4:启动测试
启动项目,看看任务是不是每秒执行一次。
就这么简单,Spring Boot 整合 Quartz 很方便。
9.3.4 两种触发器
Quartz 有两种常用的触发器:
1. SimpleTrigger(简单触发器)
类似 fixedRate,固定间隔执行。
@Bean
public Trigger simpleTrigger() {
SimpleScheduleBuilder schedule = SimpleScheduleBuilder
.simpleSchedule()
.withIntervalInSeconds(5) // 每隔 5 秒
.repeatForever(); // 永远重复
return TriggerBuilder.newTrigger()
.forJob(jobDetail())
.withIdentity("simpleTrigger")
.withSchedule(schedule)
.build();
}2. CronTrigger(Cron 触发器)
用 cron 表达式,更灵活。
@Bean
public Trigger cronTrigger() {
CronScheduleBuilder schedule = CronScheduleBuilder
.cronSchedule("0 0 2 * * ?"); // 每天凌晨 2 点
return TriggerBuilder.newTrigger()
.forJob(jobDetail())
.withIdentity("cronTrigger")
.withSchedule(schedule)
.build();
}一般用 CronTrigger 更多,更灵活。
9.3.5 给任务传参数
有时候任务需要一些参数,可以通过 JobDataMap 传递。
设置参数:
@Bean
public JobDetail jobDetail() {
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put("name", "张三");
jobDataMap.put("age", 18);
return JobBuilder.newJob(MyTask.class)
.withIdentity("myTask")
.setJobData(jobDataMap) // 设置参数
.storeDurably()
.build();
}获取参数:
public class MyTask extends QuartzJobBean {
@Override
protected void executeInternal(JobExecutionContext context) {
JobDataMap dataMap = context.getJobDetail().getJobDataMap();
String name = dataMap.getString("name");
Integer age = dataMap.getInt("age");
System.out.println("name: " + name + ", age: " + age);
}
}9.3.6 动态管理任务
Quartz 的一大优势是可以动态管理任务:创建、暂停、恢复、删除。
写一个工具类:
@Service
public class QuartzService {
@Autowired
private Scheduler scheduler;
/**
* 新增任务
*/
public void addJob(String jobName, Class<? extends Job> jobClass, String cron)
throws SchedulerException {
JobDetail jobDetail = JobBuilder.newJob(jobClass)
.withIdentity(jobName)
.storeDurably()
.build();
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity(jobName + "Trigger")
.withSchedule(CronScheduleBuilder.cronSchedule(cron))
.build();
scheduler.scheduleJob(jobDetail, trigger);
}
/**
* 暂停任务
*/
public void pauseJob(String jobName) throws SchedulerException {
scheduler.pauseJob(JobKey.jobKey(jobName));
}
/**
* 恢复任务
*/
public void resumeJob(String jobName) throws SchedulerException {
scheduler.resumeJob(JobKey.jobKey(jobName));
}
/**
* 删除任务
*/
public void deleteJob(String jobName) throws SchedulerException {
scheduler.deleteJob(JobKey.jobKey(jobName));
}
/**
* 立即执行一次
*/
public void triggerJob(String jobName) throws SchedulerException {
scheduler.triggerJob(JobKey.jobKey(jobName));
}
}这样就可以动态管理任务了,比如通过后台界面来配置定时任务。 这是 Quartz 比 Spring Schedule 强大的地方。
9.3.7 Quartz 持久化
默认情况下,Quartz 的任务存在内存里,重启就没了。
生产环境一般用 JDBC 持久化,任务存在数据库里,重启不丢。
配置:
spring:
quartz:
job-store-type: jdbc # 持久化方式:jdbc
properties:
org:
quartz:
scheduler:
instanceName: MyScheduler
instanceId: AUTO
jobStore:
class: org.springframework.scheduling.quartz.LocalDataSourceJobStore
driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
tablePrefix: QRTZ_
isClustered: true # 开启集群
threadPool:
class: org.quartz.simpl.SimpleThreadPool
threadCount: 10还要初始化数据库表,Quartz 有官方的 SQL 脚本,执行一下就行。
持久化 + 集群,是企业级应用的标配。 这部分内容比较多,知道有这么回事就行,用到的时候再深入。
9.4 邮件发送
9.4.1 邮件相关协议
先了解几个邮件相关的协议:
| 协议 | 作用 | 端口 |
|---|---|---|
| SMTP | 发送邮件 | 25(普通)/ 465(SSL) |
| POP3 | 接收邮件,下载到本地 | 110 / 995(SSL) |
| IMAP | 接收邮件,服务器上操作 | 143 / 993(SSL) |
简单理解:
- SMTP:发邮件用的
- POP3/IMAP:收邮件用的
我们这一章讲的是发送邮件,用的是 SMTP 协议。
9.4.2 Spring Boot 整合邮件
Spring Boot 对邮件发送有很好的支持,用 spring-boot-starter-mail。
步骤 1:加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>步骤 2:配置邮件
以 QQ 邮箱为例(其他邮箱类似):
spring:
mail:
host: smtp.qq.com # SMTP 服务器地址
port: 465 # 端口(QQ 用 465,SSL)
username: your@qq.com # 邮箱地址
password: your-auth-code # 授权码(不是密码!)
default-encoding: UTF-8 # 编码
properties:
mail:
smtp:
ssl:
enable: true # 开启 SSL
auth: true # 开启认证注意:这里的 password 不是邮箱密码,是授权码。 QQ 邮箱、163 邮箱都要开 SMTP 服务,获取授权码。
怎么获取授权码(QQ 邮箱):
- 登录 QQ 邮箱 → 设置 → 账户
- 找到 POP3/IMAP/SMTP 服务
- 开启 SMTP 服务
- 生成授权码
9.4.3 发送纯文本邮件
最简单的邮件,纯文本内容。
@Service
public class SendEmailService {
@Autowired
private JavaMailSender mailSender;
@Value("${spring.mail.username}")
private String from; // 发件人
/**
* 发送纯文本邮件
*/
public void sendSimpleEmail(String to, String subject, String text) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from); // 发件人
message.setTo(to); // 收件人
message.setSubject(subject); // 主题
message.setText(text); // 内容
try {
mailSender.send(message);
System.out.println("纯文本邮件发送成功");
} catch (MailException e) {
System.out.println("纯文本邮件发送失败:" + e.getMessage());
}
}
}测试:
@Test
public void testSimpleEmail() {
sendEmailService.sendSimpleEmail(
"xxx@qq.com",
"测试邮件",
"这是一封测试邮件,来自 Spring Boot。"
);
}纯文本邮件最简单,但是功能有限,不能发图片、附件、HTML。
9.4.4 发送 HTML 邮件
邮件内容可以是 HTML,支持各种样式。
/**
* 发送 HTML 邮件
*/
public void sendHtmlEmail(String to, String subject, String htmlContent)
throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true); // true 表示是 HTML
mailSender.send(message);
System.out.println("HTML 邮件发送成功");
}测试:
@Test
public void testHtmlEmail() throws MessagingException {
String html = "<h1>这是 HTML 邮件</h1>" +
"<p style='color: red'>红色文字</p>" +
"<a href='https://www.baidu.com'>百度一下</a>";
sendEmailService.sendHtmlEmail("xxx@qq.com", "HTML 邮件测试", html);
}HTML 邮件可以做得很漂亮,营销邮件一般都是 HTML 的。
9.4.5 发送带附件的邮件
邮件可以带附件,比如文件、图片等。
/**
* 发送带附件的邮件
*/
public void sendEmailWithAttachment(String to, String subject, String text,
String filePath) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
// multipart=true 表示支持多部件(附件、图片等)
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(text);
// 添加附件
FileSystemResource file = new FileSystemResource(new File(filePath));
String fileName = filePath.substring(filePath.lastIndexOf(File.separator) + 1);
helper.addAttachment(fileName, file);
mailSender.send(message);
System.out.println("带附件的邮件发送成功");
}测试:
@Test
public void testAttachmentEmail() throws MessagingException {
sendEmailService.sendEmailWithAttachment(
"xxx@qq.com",
"带附件的邮件",
"请看附件",
"C:/test.pdf"
);
}可以添加多个附件,调用多次 addAttachment 就行。
9.4.6 发送带图片的邮件
图片可以直接在邮件内容里显示,不用下载附件。
/**
* 发送带静态资源(图片)的邮件
*/
public void sendEmailWithInline(String to, String subject, String text,
String rscId, String rscPath) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
// HTML 里用 cid:xxx 引用图片
helper.setText(text, true);
// 添加内联图片
FileSystemResource res = new FileSystemResource(new File(rscPath));
helper.addInline(rscId, res);
mailSender.send(message);
System.out.println("带图片的邮件发送成功");
}测试:
@Test
public void testInlineEmail() throws MessagingException {
String html = "<h1>这是带图片的邮件</h1>" +
"<img src='cid:logo'/>"; // cid: 对应 rscId
sendEmailService.sendEmailWithInline(
"xxx@qq.com",
"带图片的邮件",
html,
"logo",
"C:/logo.png"
);
}注意:rscId 要跟 HTML 里的 cid:xxx 对应上。
9.4.7 发送模板邮件(重点)
实际项目中,邮件内容一般是动态的,比如注册成功邮件,用户名是动态的。
这时候用模板引擎(Thymeleaf)来生成邮件内容。
步骤 1:加 Thymeleaf 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>步骤 2:写邮件模板
在 resources/templates/ 下创建 emailTemplate.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>注册成功</title>
</head>
<body>
<h2>尊敬的 <span th:text="${username}"></span>,您好!</h2>
<p>恭喜您注册成功!</p>
<p>您的账号信息:</p>
<ul>
<li>用户名:<span th:text="${username}"></span></li>
<li>注册时间:<span th:text="${registerTime}"></span></li>
</ul>
<p>感谢您的加入!</p>
<hr>
<p style="color: #999; font-size: 12px;">
此邮件由系统自动发送,请勿回复。
</p>
</body>
</html>步骤 3:发送模板邮件
@Service
public class SendEmailService {
@Autowired
private JavaMailSender mailSender;
@Autowired
private TemplateEngine templateEngine;
@Value("${spring.mail.username}")
private String from;
/**
* 发送模板邮件
*/
public void sendTemplateEmail(String to, String subject,
String templateName, Map<String, Object> variables)
throws MessagingException {
// 用模板引擎生成 HTML 内容
Context context = new Context();
context.setVariables(variables);
String htmlContent = templateEngine.process(templateName, context);
// 发送 HTML 邮件
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true);
mailSender.send(message);
System.out.println("模板邮件发送成功");
}
}测试:
@Test
public void testTemplateEmail() throws MessagingException {
Map<String, Object> variables = new HashMap<>();
variables.put("username", "张三");
variables.put("registerTime", LocalDateTime.now().toString());
sendEmailService.sendTemplateEmail(
"xxx@qq.com",
"注册成功",
"emailTemplate", // 模板名
variables
);
}模板邮件是实际项目中最常用的方式。 内容和代码分离,改模板不用改代码。
9.5 企业最佳实践
9.5.1 异步任务最佳实践
一定要自定义线程池
- 不要用默认的,默认每次新建线程
- 根据业务设置合理的线程数
处理异常
- 无返回值的异步任务,异常要自己处理
- 记录日志,必要时告警
监控线程池状态
- 活跃线程数、队列大小、完成任务数
- 防止线程池满了任务丢了
合理使用有返回值的异步
- 不要为了异步而异步
- 真正能并行的才用
9.5.2 定时任务最佳实践
任务幂等性
- 定时任务可能重复执行(重启、重试)
- 要保证执行一次和执行多次结果一样
任务不要太长
- 单次执行时间不要太长
- 大数据量的任务考虑分片、分页处理
集群部署要去重
- 多个实例的话,定时任务会执行多次
- 用分布式锁(Redis、ZooKeeper)
- 或者用 Quartz 集群模式
失败重试
- 任务失败了要不要重试
- 重试几次?间隔多久?
监控告警
- 任务是否正常执行
- 执行时间是否异常
- 失败了要告警
9.5.3 邮件发送最佳实践
异步发送
- 发邮件比较慢,不要同步发
- 用异步或者消息队列
失败重试
- 网络问题可能导致发送失败
- 要有重试机制
频率控制
- 不要发太频繁,不然会被当成垃圾邮件
- 同一收件人限制发送频率
模板管理
- 邮件内容用模板
- 模板可以存在数据库,支持动态修改
邮件日志
- 记录每封邮件的发送情况
- 发送时间、收件人、内容、状态
退订机制
- 营销邮件要有退订链接
- 合规要求
9.6 新手常见问题排查
问题 1:异步任务不生效
可能原因:
- 没加
@EnableAsync - 方法不是 public 的
- 方法在同一个类里调用(AOP 不生效)
- 类没被 Spring 管理
排查:
- 检查启动类有没有 @EnableAsync
- 方法要是 public 的
- 异步方法要在不同的类里调用(自己调自己不行)
- 类上要有 @Service / @Component
重点:同一个类里调用异步方法不生效! 因为 AOP 代理的问题,要从外部类调用。
问题 2:定时任务不执行
可能原因:
- 没加
@EnableScheduling - 方法不是 public 的
- 方法有参数
- 类没被 Spring 管理
- cron 表达式写错了
排查:
- 检查启动类有没有 @EnableScheduling
- 方法要是 public 的,不能有参数
- 类上要有 @Service / @Component
- 检查 cron 表达式对不对
问题 3:邮件发送失败
可能原因:
- 配置不对(host、port、username、password)
- 授权码不对(不是邮箱密码)
- 没开 SMTP 服务
- 网络问题
- 被当成垃圾邮件了
排查:
- 检查配置是否正确
- 确认开启了 SMTP 服务,用的是授权码
- 检查端口和 SSL 配置
- 检查网络能不能连通 SMTP 服务器
- 看看垃圾箱里有没有
问题 4:Quartz 任务不执行
可能原因:
- JobDetail 或 Trigger 没配置对
- cron 表达式错了
- 任务类没被 Spring 管理
- 任务抛异常了
排查:
- 检查 JobDetail 和 Trigger 的 Bean 配置
- 检查 cron 表达式
- 任务类继承 QuartzJobBean
- 看看日志有没有异常
问题 5:定时任务在集群里重复执行
原因: 每个实例都执行了一遍
解决:
- 用分布式锁(Redis 锁)
- 用 Quartz 集群模式
- 只在一个节点上跑定时任务
9.7 本章小结
恭喜你!任务调度和邮件发送这一章学完了!
这一章内容比较实用,都是项目里经常用到的。
你都学了什么
异步任务:
- 什么是异步任务
- @EnableAsync + @Async
- 无返回值异步
- 有返回值异步(Future)
- 自定义线程池
- 异常处理
定时任务(Spring Schedule):
- @EnableScheduling + @Scheduled
- fixedRate / fixedDelay / initialDelay
- Cron 表达式详解
- 常用 cron 例子
- 注意事项
Quartz 任务调度:
- Quartz 简介
- 核心概念(Job、JobDetail、Trigger、Scheduler)
- Spring Boot 整合
- 两种触发器(Simple、Cron)
- 任务传参
- 动态管理任务
- 持久化简介
邮件发送:
- 邮件协议简介
- Spring Boot 整合邮件
- 纯文本邮件
- HTML 邮件
- 带附件的邮件
- 带图片的邮件
- 模板邮件(Thymeleaf)
企业最佳实践:
- 异步任务最佳实践
- 定时任务最佳实践
- 邮件发送最佳实践
动手实践清单
| 实践项 | 做完打勾 |
|---|---|
| 能实现简单的异步任务 | ☐ |
| 理解异步方法调用的注意事项(同类调用不生效) | ☐ |
| 会用有返回值的异步任务(Future) | ☐ |
| 能自定义线程池 | ☐ |
| 能实现简单的定时任务 | ☐ |
| 理解 fixedRate 和 fixedDelay 的区别 | ☐ |
| 会写常用的 cron 表达式 | ☐ |
| 知道定时任务的注意事项(幂等、集群问题) | ☐ |
| 了解 Quartz 的核心概念 | ☐ |
- 能用 Quartz 实现定时任务 | ☐ | | 知道 Quartz 和 Spring Schedule 的区别 | ☐ | | 能发送纯文本邮件 | ☐ | | 能发送 HTML 邮件 | ☐ | | 能发送带附件的邮件 | ☐ | | 能发送模板邮件 | ☐ |
面试常问
Spring Boot 怎么实现异步任务?
- @EnableAsync 开启异步
- @Async 标记异步方法
- 自定义线程池
- 注意同类调用不生效
fixedRate 和 fixedDelay 的区别?
- fixedRate:从上一次开始时间算,固定频率
- fixedDelay:从上一次结束时间算,固定间隔
- 任务执行时间超过间隔时表现不同
Cron 表达式格式?常用的有哪些?
- 秒 分 时 日 月 周 年
- 每天凌晨、每隔 5 分钟、周一到周五等
- 日和周互斥,一个用 ?
Spring Schedule 和 Quartz 的区别?
- Spring Schedule:简单、轻量、适合简单任务
- Quartz:功能强大、支持持久化、集群、动态管理
- 根据需求选择
Quartz 的核心概念?
- Job:任务
- JobDetail:任务详情
- Trigger:触发器
- Scheduler:调度器
集群环境下定时任务怎么避免重复执行?
- 分布式锁(Redis、ZooKeeper)
- Quartz 集群模式
- 只在一个节点执行
Spring Boot 怎么发送邮件?
- 引入 spring-boot-starter-mail
- 配置 SMTP 信息
- 用 JavaMailSender 发送
- 支持纯文本、HTML、附件、模板
异步任务和消息队列怎么选?
- 简单的、同一个应用内的:用异步任务
- 跨系统的、需要解耦的、需要削峰的:用消息队列
- 需要持久化、需要可靠投递的:用消息队列
给实习生的建议
异步任务很常用:发邮件、发短信、记录日志,这些耗时的操作都应该异步。
Cron 表达式不用死记:用到的时候查一下,或者用在线生成器。常用的几个记住就行。
简单任务用 Spring Schedule:不要上来就用 Quartz,简单的定时任务 Spring Schedule 就够了。
复杂调度用 Quartz:需要动态管理、持久化、集群的,再用 Quartz。
邮件发送注意异步:发邮件比较慢,不要同步发,影响用户体验。
幂等性很重要:定时任务、异步任务、消息队列,都要考虑幂等性。不要假设消息/任务只会来一次。
监控和告警:定时任务失败了、邮件发不出去了,要能及时发现。生产环境监控很重要。
下一章我们学习 Spring Boot 综合项目实战(瑞吉外卖),把前面学的知识综合运用起来。加油!